The inventory is implemented as a reusable component attachable to any actor. Interaction with it is handled via the Gameplay Ability System (GAS) using GameplayTags.
Internally, the design takes inspiration from the Lyra project’s inventory component. Items are represented by an Item Actor containing its static data, which in this implementation also includes animation metadata, allowing the system to play context specific animations based on the currently equipped item.
// Item Actor class
UCLASS()
class ACTIONGAME_API AItemActor : public AActor
{
GENERATED_BODY()
public:
// Sets default values for this actor's properties
AItemActor();
virtual void OnEquipped();
virtual void OnUnequipped();
virtual void OnDropped();
virtual bool ReplicateSubobjects(class UActorChannel* Channel, class FOutBunch* Bunch, FReplicationFlags* RepFlags) override;
void Init(UInventoryItemInstance* InItemInstance);
protected:
// Called when the game starts or when spawned
virtual void BeginPlay() override;
UPROPERTY(Replicated)
UInventoryItemInstance* ItemInstance = nullptr;
UPROPERTY(ReplicatedUsing = OnRep_ItemState)
TEnumAsByte<EItemState> ItemState = EItemState::None;
UFUNCTION()
void OnRep_ItemState();
UPROPERTY()
USphereComponent* SphereComponent = nullptr;
UFUNCTION()
void OnSphereOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult);
UPROPERTY(EditDefaultsOnly)
TSubclassOf<UItemStaticData> ItemStaticDataClass;
public:
// Called every frame
virtual void Tick(float DeltaTime) override;
};
// The items static data
UCLASS(BlueprintType, Blueprintable)
class UItemStaticData : public UObject
{
GENERATED_BODY()
public:
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
FName Name;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
TSubclassOf<AItemActor> ItemActorClass;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
FName AttachmentSocket = NAME_None;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
bool bCanBeEquipped = false;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
FCharacterAnimationData CharacterAnimationData;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
TObjectPtr<UTexture2D> Image = nullptr;
};
The actors are wrapped in ItemInstance handles that store both the actor reference and its static data.
// Item Instance class
UCLASS()
class ACTIONGAME_API UInventoryItemInstance : public UObject
{
GENERATED_BODY()
public:
virtual void Init(TSubclassOf<UItemStaticData> InItemStaticDataClass);
virtual bool IsSupportedForNetworking() const override { return true; }
UFUNCTION(BlueprintCallable, BlueprintPure)
const UItemStaticData* GetItemStaticData();
UPROPERTY(Replicated)
TSubclassOf<UItemStaticData> ItemStaticDataClass;
UPROPERTY(ReplicatedUsing = OnRep_Equipped)
bool bEquipped = false;
UFUNCTION()
void OnRep_Equipped();
virtual void OnEquipped(AActor* InOwner = nullptr);
virtual void OnUnequipped();
virtual void OnDropped();
protected:
UPROPERTY(Replicated)
AItemActor* ItemActor = nullptr;
};
These instances are encapsulated within FFastArraySerializerItem structures for efficient network replication, all managed by an FFastArraySerializer container that represents the actual inventory list within the component.
// FastArraySerializer for efficient network replication
USTRUCT(BlueprintType)
struct FInventoryListItem : public FFastArraySerializerItem
{
GENERATED_BODY()
public:
UPROPERTY(BlueprintReadOnly)
UInventoryItemInstance* ItemInstance = nullptr;
FORCEINLINE bool operator==(const FInventoryListItem& Other) const { return ItemInstance == Other.ItemInstance; }
FORCEINLINE bool operator!=(const FInventoryListItem& Other) const { return !(*this == Other); }
};
// The actual container for our inventory items held by the InventoryComponent
USTRUCT(BlueprintType)
struct FInventoryList : public FFastArraySerializer
{
GENERATED_BODY()
bool NetDeltaSerialize(FNetDeltaSerializeInfo& DeltaParams)
{
return FFastArraySerializer::FastArrayDeltaSerialize<FInventoryListItem, FInventoryList>(Items, DeltaParams, *this);
}
void AddItem(TSubclassOf<UItemStaticData> InItemStaticDataClass);
void AddItem(UInventoryItemInstance* InItemInstance);
void RemoveItem(TSubclassOf<UItemStaticData> InItemStaticDataClass);
void RemoveItemInstance(UInventoryItemInstance* InItemInstance);
TArray<FInventoryListItem>& GetItemsRef() { return Items; }
protected:
UPROPERTY(BlueprintReadOnly)
TArray<FInventoryListItem> Items;
};
// IMPORTANT: To enable net delta serialization, set WithNetDeltaSerializer to true
template<>
struct TStructOpsTypeTraits<FInventoryList> : public TStructOpsTypeTraitsBase2<FInventoryList>
{
enum { WithNetDeltaSerializer = true };
};
To manage our inventory through GameplayTag-based events, we need to follow a few steps. First, we initialize the tags inside the component’s constructor.
// Static Tags
FGameplayTag UInventoryComponent::EquipItemActorTag;
FGameplayTag UInventoryComponent::UnequipItemTag;
FGameplayTag UInventoryComponent::DropItemTag;
FGameplayTag UInventoryComponent::EquipNextTag;
FGameplayTag UInventoryComponent::EquipPreviousTag;
// Sets default values for this component's properties
UInventoryComponent::UInventoryComponent()
{
PrimaryComponentTick.bCanEverTick = true;
bWantsInitializeComponent = true;
SetIsReplicatedByDefault(true);
// small check to see if tags have allready been added and if so, dont allow them to be added again
static bool bHandledAddingTags = false;
{
if (!bHandledAddingTags)
{
bHandledAddingTags = true;
UGameplayTagsManager::Get().OnLastChanceToAddNativeTags().AddUObject(this, &UInventoryComponent::AddInventoryTags);
}
}
}
void UInventoryComponent::AddInventoryTags()
{
UGameplayTagsManager& TagsManager = UGameplayTagsManager::Get();
UInventoryComponent::EquipItemActorTag = TagsManager.AddNativeGameplayTag(TEXT("Event.Inventory.EquipItemActor"), TEXT("Equip item from item actor event"));
UInventoryComponent::UnequipItemTag = TagsManager.AddNativeGameplayTag(TEXT("Event.Inventory.UnequipItem"), TEXT("Unequip current item"));
UInventoryComponent::DropItemTag = TagsManager.AddNativeGameplayTag(TEXT("Event.Inventory.DropItem"), TEXT("Drop equipped item"));
UInventoryComponent::EquipNextTag = TagsManager.AddNativeGameplayTag(TEXT("Event.Inventory.EquipNext"), TEXT("Try equip next item"));
UInventoryComponent::EquipPreviousTag = TagsManager.AddNativeGameplayTag(TEXT("Event.Inventory.EquipPrevious"), TEXT("Try equip previous item"));
TagsManager.OnLastChanceToAddNativeTags().RemoveAll(this);
}
Actions such as equipping, unequipping, and dropping items are transmitted as GenericGameplayEvents containing a GameplayTag and an optional payload. These are sent through the inventory component’s owner’s AbilitySystemComponent. To receive such events, we first need to register callbacks using the owner’s AbilitySystemComponent.
void UInventoryComponent::InitializeComponent()
{
Super::InitializeComponent();
if (GetOwner()->HasAuthority())
{
for (auto ItemClass : DefaultItems)
{
InventoryList.AddItem(ItemClass);
}
}
// Subscribe Events to Tag Callbacks
if (UAbilitySystemComponent* ASC = UAbilitySystemBlueprintLibrary::GetAbilitySystemComponent(GetOwner()))
{
ASC->GenericGameplayEventCallbacks.FindOrAdd(UInventoryComponent::EquipItemActorTag).AddUObject(this, &UInventoryComponent::GameplayEventCallback);
ASC->GenericGameplayEventCallbacks.FindOrAdd(UInventoryComponent::UnequipItemTag).AddUObject(this, &UInventoryComponent::GameplayEventCallback);
ASC->GenericGameplayEventCallbacks.FindOrAdd(UInventoryComponent::DropItemTag).AddUObject(this, &UInventoryComponent::GameplayEventCallback);
ASC->GenericGameplayEventCallbacks.FindOrAdd(UInventoryComponent::EquipNextTag).AddUObject(this, &UInventoryComponent::GameplayEventCallback);
ASC->GenericGameplayEventCallbacks.FindOrAdd(UInventoryComponent::EquipPreviousTag).AddUObject(this, &UInventoryComponent::GameplayEventCallback);
}
}
// Callback Handler for Server and Client
void UInventoryComponent::GameplayEventCallback(const FGameplayEventData* Payload)
{
ENetRole NetRole = GetOwnerRole();
if (NetRole == ROLE_Authority)
{
HandleGameplayEventInternal(*Payload);
}
else if (NetRole == ROLE_AutonomousProxy)
{
Server_HandleGameplayEvent(*Payload);
}
}
// Actual handling of the Event
void UInventoryComponent::HandleGameplayEventInternal(FGameplayEventData Payload)
{
ENetRole NetRole = GetOwnerRole();
if (NetRole == ROLE_Authority)
{
FGameplayTag EventTag = Payload.EventTag;
if (EventTag == UInventoryComponent::EquipItemActorTag)
{
if (UInventoryItemInstance* ItemInstance = Cast<UInventoryItemInstance>(Payload.OptionalObject))
{
AddItemInstance(ItemInstance);
if (Payload.Instigator)
{
Cast<AActor>(Payload.Instigator)->Destroy();
}
}
}
else if (EventTag == UInventoryComponent::UnequipItemTag)
{
UnequipItem();
}
else if (EventTag == UInventoryComponent::DropItemTag)
{
DropItem();
}
else if (EventTag == UInventoryComponent::EquipNextTag)
{
EquipNext();
}
else if (EventTag == UInventoryComponent::EquipPreviousTag)
{
EquipPrevious();
}
UpdateInventoryUI();
}
}
After that, we can send generic events to the owner’s AbilitySystemComponent. This can be triggered via player input actions or by other events, such as picking up an item through overlap detection.
// From the Character
void AActionGameCharacter::OnDropItemActionTriggered(const FInputActionValue& Value)
{
FGameplayEventData EventPayload;
EventPayload.EventTag = UInventoryComponent::DropItemTag;
UAbilitySystemBlueprintLibrary::SendGameplayEventToActor(this, UInventoryComponent::DropItemTag, EventPayload);
}
void AActionGameCharacter::OnEquipNextActionTriggered(const FInputActionValue& Value)
{
FGameplayEventData EventPayload;
EventPayload.EventTag = UInventoryComponent::EquipNextTag;
UAbilitySystemBlueprintLibrary::SendGameplayEventToActor(this, UInventoryComponent::EquipNextTag, EventPayload);
}
void AActionGameCharacter::OnEquipPreviousActionTriggered(const FInputActionValue& Value)
{
FGameplayEventData EventPayload;
EventPayload.EventTag = UInventoryComponent::EquipPreviousTag;
UAbilitySystemBlueprintLibrary::SendGameplayEventToActor(this, UInventoryComponent::EquipPreviousTag, EventPayload);
}
void AActionGameCharacter::OnUnequipActionTriggered(const FInputActionValue& Value)
{
FGameplayEventData EventPayload;
EventPayload.EventTag = UInventoryComponent::UnequipItemTag;
UAbilitySystemBlueprintLibrary::SendGameplayEventToActor(this, UInventoryComponent::UnequipItemTag, EventPayload);
}
// From a pickupable Actor:
void AItemActor::OnSphereOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
if (HasAuthority())
{
if (const AActionGameCharacter* Character = Cast<AActionGameCharacter>(OtherActor))
{
if (const UInventoryComponent* InventoryComponent = Character->GetInventoryComponent())
{
// TODO: Hardcoded to limit items held to 5, change in the future
if (InventoryComponent->GetInventoryList().GetItemsRef().Num() >= 5)
return;
}
}
FGameplayEventData EventPayload;
EventPayload.Instigator = this;
EventPayload.OptionalObject = ItemInstance;
EventPayload.EventTag = UInventoryComponent::EquipItemActorTag;
UAbilitySystemBlueprintLibrary::SendGameplayEventToActor(OtherActor, UInventoryComponent::EquipItemActorTag, EventPayload);
}
}
This design allows flexible triggering of inventory actions, whether from button presses, item pickups, or environmental interactions.
Another important step is to enable replication for the ItemInstances, which can change dynamically. This is done by overriding ReplicateSubobjects so each instance is synchronized between server and clients.
// Needed for replication of dynamically added and removed subobjects
bool UInventoryComponent::ReplicateSubobjects(UActorChannel* Channel, FOutBunch* Bunch, FReplicationFlags* RepFlags)
{
bool WroteSomething = Super::ReplicateSubobjects(Channel, Bunch, RepFlags);
for (FInventoryListItem& Item : InventoryList.GetItemsRef())
{
UInventoryItemInstance* ItemInstance = Item.ItemInstance;
if (IsValid(ItemInstance))
{
WroteSomething |= Channel->ReplicateSubobject(ItemInstance, *Bunch, *RepFlags);
}
}
return WroteSomething;
}
For the visual layer, the Model–View–Controller pattern is used. Inventory data flows through a chain of events from the controller to the widgets, ensuring a clean separation of concerns.
We initialize the overlay only after ensuring that either the server or client is valid. On the server, this call is made after the PossessedBy() function inside the character class, on the client, we use the OnRep_PlayerState() function. When the overlay is initialized, all UI controllers receive the event that the data is valid.
Through a chain of events from the InventoryComponent to the InventoryUIController and then to the InventoryUIWidget, we can access a list of all items held as well as the index of the currently equipped item. This information is used by the widget to display the inventory data.
As we only hold a maximum of 5 items at a time, we can destroy and rebuild the UI every time we receive an update without any significant performance loss.
Currently, the system supports, and fully replicates in multiplayer, the following features:
The system’s modular design allows for straightforward extension, enabling features such as item stacking or evolving into a full-fledged inventory system with additional capabilities. Its flexible architecture ensures scalability and adaptability to various gameplay requirements without major refactoring.
If you want to create your own inventory system based on what you have seen, feel free to check out the project’s GitHub page below the video. Note, however, that only the C++ classes have been included, no .uasset files.